# vBulletin module
# Developed for version 3.7

import forumapi
from forumapi.vbulletin.BBCode import BBCode
from forumapi.vbulletin import Attachment
from forumapi.vbulletin.PagedResult import PagedResult

from deps import BeautifulSoup
from deps.Form import Form
from deps.MagicDate import magicdate
from deps.PageRequest import PageRequest

import datetime
import re

## Exceptions ##

class DateTimeException(forumapi.ForumAPIException):
	"""Indicates an invalid date and/or time string."""

NotImplementedException = forumapi.NotImplementedException

## Hooks ##

def _form_retrieve_input_securitytoken(form=None, name=None, type=None, value=None, **kwargs):
	if "session" in form.kw:
		if name == "securitytoken" and type == "hidden":
			form.kw["session"].security_token = value

Form.hooks.add("retrieve_input", _form_retrieve_input_securitytoken)

## Main Classes ##

class Language(forumapi.Language):
	phrases_parser = None
	
	def __init__(self, code=None):
		super().__init__(code)
		
		self._phrases = None
	
	@property
	def phrases(self):
		if self._phrases is None:
			if self.__class__.phrases_parser is None:
				self.__class__.phrases_parser = forumapi.ConfigParser()
				self.__class__.phrases_parser.read(forumapi.data_file("phrases", "cfg"))
			
			section = self.__class__.phrases_parser.get_section(self.code)
			self._phrases = Phrases(self, section)
			
			return(self._phrases)
		else:
			return(self._phrases)

class Phrases(object):
	def __init__(self, lang, section):
		self.lang = lang
		self.phrases = section
	
	def __getitem__(self, key):
		if isinstance(key, (int, slice)):
			return(tuple(self.phrases.items())[key])
		elif key in self.phrases:
			return(self.phrases[key])
		elif self.lang.fallback:
			return(self.lang.fallback.phrases[key])
		else:
			raise(KeyError(key))
	
	def __contains__(self, key):
		if key in self.phrases:
			return(True)
		elif self.lang.fallback:
			return(key in self.lang.fallback.phrases)
		else:
			return(False)
	
	def __eq__(self, other):
		return(self.lang == other.lang)
	
	def __repr__(self):
		return("<Phrases for=\"{0}\">".format(self.lang.code))

class Session(forumapi.Session):
	"""Handles actions that are to be taken with the site, such as logging in
	and posting."""
	
	def __init__(self, site, *args, cookies=None, **kw):
		"""Creates cookies.
		
		If this is passed parameters, the following sessions can be
		started, depending on the number of parameters:
		  2 = session from userid and password hash
		  1 = session from session hash
		  0 = guest session
		
		If this is passed the `cookies` param, they will be used to initialize
		the session. Cookies are not checked for validity."""
		
		super().__init__(site, **kw)
		
		options = site.settings.get_settings_section("vbulletin")
		if "bbcode_file" in options:
			self.bbcode_file = options["bbcode_file"]
		else:
			self.bbcode_file = ""
		
		self._forums = None
		
		if cookies:
			self.set_cookies(cookies)
			
			if site.cookie_prefix + "userid" in cookies:
				self.user = User(self, id=int(cookies[site.cookie_prefix + "userid"]))
		else:
			if len(args) == 0:
				self.guest = True
				self.user = User(self, id=0)
			elif len(args) == 1:
				self.set_cookies({site.cookie_prefix + "sessionhash": args[0]})
			elif len(args) == 2:
				self.set_cookies({
					site.cookie_prefix + "userid": args[0],
					site.cookie_prefix + "password": args[1],
				})
				self.user = User(self, id=args[0])
			else:
				raise(forumapi.SessionError("Invalid number of parameters to instatiate user."))
	
	@classmethod
	def from_login(cls, site, username, password, kw):
		"""Logs into the site using a username and password, returning an
		authenticated session.
		
		Note that the password is sent as plaintext."""
		
		post_data = {
			"vb_login_username": username,
			"vb_login_password": password,
			"securitytoken": "guest",
			"do": "login",
			"cookieuser": "1",
		}
		
		url = site.url + "login.php?do=login"
		
		response = PageRequest(url, post_data=post_data, encoding=site.encoding)
		
		cookies = forumapi.get_cookies(response)
		if not cookies:
			# Probably a login error
			raise(forumapi.SessionError("No cookies obtained to instantiate a session."))
		
		session = cls(site, cookies=cookies, **kw)
		if session.user:
			session.user._name = username
		else:
			session.user = User(session, name=username)
		
		return(session)
	
	@property
	def forums(self):
		"""Returns a list of `Forum` objects from the index page. Only the
		first level is returned; the rest is available as indexes of those
		forums `subforums` properties."""
		
		if self._forums is not None:
			return(self._forums)
		else:
			self._forums = self.get_forums_list(loc="index")
			return(self._forums)
	
	def get_page(self, page="", method=None, post_data=None):
		response = super().get_page(page=page, method=method, post_data=post_data)
		
		if self.language == "":
			soup = self.soup(response)
			html = soup.find("html")
			if html and "lang" in dict(html.attrs):
				self.language = html["lang"]
		
		return(response)
	
	def check_logged_in(self, response, path=None):
		"""Given a `PageRequest`, returns a boolean representing whether the
		user is logged in or not, or `None` if it's not known."""
		
		if path == "ajax.php" or path == "showpost.php":
			# Can't check
			return(None)
		
		soup = self.soup(response)
		
		table = soup.find("table", "tborder")
		if not table:
			return(None)
		
		welcome_soup = table.find("div", "smallfont")
		
		if welcome_soup:
			user_link = welcome_soup.find("a")
			
			user_id = int(forumapi.qs_dict_from_url(user_link["href"])["u"][0])
				
			user_name = str(user_link.string)
			
			if self.user is None:
				self.user = User(self, id=user_id, name=user_name)
			else:
				if self.user._id is None:
					self.user._id = user_id
				if self.user._name is None:
					self.user._name = user_name
			
			return(True)
		else:
			return(False)
	
	def get_number(self, number):
		"""Given a formatted number, returns an integer or float of it using
		language options."""
		
		num = str(number).replace(self.lang["thousands_sep"], "")
		if self.lang["decimal_sep"] in num:
			return(float(num.replace(self.lang["decimal_sep"], ".")))
		else:
			return(int(num))
	
	def get_datetime(self, dt, format=None):
		"""Given a formatted date and time, returns a datetime, date or time
		object that represents it, using language options and expansion of
		magic dates	like "yesterday".
		
		If given a string and a `format` parameter, will try to use that format
		to parse the date or time. It is not used if the function can detect
		that both a date and a time are being used."""
		
		if isinstance(dt, str):
			if ", " in dt:
				(date, time) = dt.strip().split(", ", 1)
			else:
				try:
					return(self.get_date(dt, formats=format))
				except:
					return(self.get_time(dt, formats=format))
		elif isinstance(dt, (list, tuple)) and len(dt) >= 2:
			(date, time) = dt
		else:
			try:
				dateline = str(dt.contents[0]).strip()
			except:
				dateline = "today"
			
			time_soup = dt.find("span", "time")
			if time_soup and time_soup.string:
				date = dateline
				time = str(time_soup.string)
			else:
				if ", " in dateline:
					(date, time) = dateline.split(", ", 1)
				else:
					try:
						return(self.get_date(dateline, formats=format))
					except:
						return(self.get_time(dateline, formats=format))
		
		return(datetime.datetime.combine(self.get_date(date), self.get_time(time)))
	
	def get_date_time(self, date_time, formats=[]):
		"""Returns a datetime object representing a given formatted date or time
		string."""
		
		if isinstance(formats, str):
			formats = [formats]
		elif formats is None:
			formats = []
		
		date_time = date_time.strip()
		for format in formats:
			try:
				return(datetime.datetime.strptime(date_time, self.lang[format]))
			except ValueError:
				pass
		
		raise(DateTimeException("Could not create datetime object from string"))
	
	def get_date(self, date, formats=None):
		"""Returns a date object representing a given formatted date string."""
		
		try:
			d = magicdate(date)
			if isinstance(d, datetime.date):
				return(d)
		except:
			pass
		
		if formats is None:
			formats = "date_format"
		
		return(self.get_date_time(date, formats=formats).date())
	
	def get_time(self, time, formats=None):
		"""Returns a time object representing a given formatted time string."""
		
		try:
			d = magicdate(time)
			if isinstance(d, datetime.time):
				return(d)
		except:
			pass
		
		if formats is None:
			formats = "time_format"
		
		return(self.get_date_time(time, formats=formats).time())
	
	def get_size(self, size):
		"""Returns a tuple of (size, magnitude) given text of a size containing
		a magnitude size unit (bytes, KB, ...)."""

		(val, unit) = size.split(" ")
		val = self.get_number(val)

		if unit == self.lang.phrases["size_bytes"]:
			return((val, 1))
		elif unit == self.lang.phrases["size_kilobytes"]:
			return((val, 1024))
		elif unit == self.lang.phrases["size_megabytes"]:
			return((val, 1024*1024))
		elif unit == self.lang.phrases["size_gigabytes"]:
			return((val, 1024*1024*1024))
		else:
			return(None)
	
	def get_error(self, soup=None, response=None):
		"""Retrieves an error message from a page response or a `BeautifulSoup`
		of such response.
		
		This searches for the "vBulletin Message" panel and retrieves the most
		important message from it. Returns `None` if the panel cannot be found.
		
		This can be used to create exceptions, since there errors usually
		indicate a `PermissionError`. However, this should only be used if the
		caller already expects an error and is simply looking for the reason."""
		
		if soup is None:
			if response is None:
				raise(ValueError("Need either page or soup."))
			
			soup = self.soup(response)
		
		panel = soup.find("div", {"class": "panel"})
		if not panel:
			return(None)
		
		error = panel.find("div")
		if not div:
			return(None)
		
		# Only show most important error
		if len(error.contents) >= 2 and isinstance(error.contents[1], BeautifulSoup.Comment) and str(error.contents[1]).strip() == 'main error message':
			return(forumapi.remove_tags(error.find("div")))
		else:
			return(forumapi.remove_tags(error).strip().split("\n")[0])
	
	def get_security_token(self, form_data=None, page=None, soup=None):
		"""Retrieves a security token to be used in form submits.
		
		If `form_data` is given, it is assumed to be a list of form values or
		a `Form` object. It will be used if it contains a security token.
		If `soup` is given, it is assumed to be a `BeautifulSoup` of a form.
		If `page` is given, the page will be requested, and the first form on
		the page will be used.
		If `form_data` is not useful and neither `soup` nor `page` is given,
		the search form on "search.php" will be tested."""
		
		if self.guest:
			return("guest")
		elif self.security_token:
			return(self.security_token)
		elif not form_data or "securitytoken" not in form_data:
			if soup is None:
				if page is None:
					page = "search.php"
				
				soup = self.soup(self.get_page(page)).find("form")
			
			form_data = Form(soup)
		
		self.security_token = form_data["securitytoken"]
		return(self.security_token)
	
	def new_security_token(self):
		"""Requests a new security token and stores it."""
		
		ajax_data = {
			"securitytoken": self.get_security_token(),
			"do": "securitytoken",
		}
		
		page = self.get_page("ajax.php", post_data=ajax_data)
		
		xml_soup = self.soup(page, "xml")
		
		securitytoken = xml_soup.find("securitytoken")
		
		if securitytoken.string:
			self.security_token = str(securitytoken.string)
		else:
			raise(UnknownError("Could not retrieve new security token."))
	
	def get_search_results(self, *a, special=None, pages=1, start=1, t=None, p=None, **kw):
		"""Performs a search.
		
		A few special cases are implemented, using the `special` parameter:
		
		- Today's posts (`special="today"`)
		- Unread posts (`special="new"`)
		
		Otherwise, a normal search is done, with optional search preferences,
		settable via parameters:
		
		- Text arguments as search query (`"foo"`, `"bar"`)
		- `titleonly=True`, to search the text in titles only
		- `user`, which is a `User` object or user ID
		- `starteronly=True`, to only show threads started by the user
		- `thread`, which is a `Thread` object or thread ID to search in
		- `prefixes`, which is a list of thread prefixes, or `-1` to require
		   that there is *no* thread prefix
		- `minposts` or `maxposts`, to limit the mininimal or maximal number of
		   posts in the threads (cannot be used together)
		- `tag`, which is a `Tag` object or tag name
		- `forums`, which is a list of `Forum` objects or forum IDs
		- `subforums=False`, to not search child forums of those given in the
		  `forums` parameter
		- `showposts=True`, to return a list of `Post` objects
		
		If `tag` is the only parameter given, a simple tag search will be done.
		This skips the human verification if there is one on guest searches.
		Note that searching with a tag as a parameter are only enabled if the
		`Tag` submodule has been explicitly or implicitly loaded.
		Likewise, a simple user search is done when only `user` and optionally
		`starteronly` is given.
		Using the `thread` parameter starts a search in that thread, which
		allows less parameters than a normal search.
		
		The `pages` parameter can be used to modify the limit on the number of
		results. See `Session.get_pages()` and `PagedResult` for more details.

		The `t` parameter can be set to a `Thread` that	is to be found in the
		threads list, or in case of a post search, the `p` parameter can be set
		to a `Post` that is to be found in the search posts list. If `pages` is
		set to `"all"`, then it will stop once that thread or post is found.
		
		The result of a search is a `PagedResult` list of either `Thread` or
		`Post` objects, depending on the boolean value of the `showposts`
		parameter (or, in case of a user search, the `starteronly` parameter).
		Searches using the `thread` parameter aways return Post objects."""
		
		if pages != "all" and pages < 1:
			raise(ValueError("Number of pages must be greater than zero."))
		
		callback = self.get_threads_list
		search_page = ""
		search_data = {}
		method = "get"
		
		if special:
			if kw or a:
				# TODO: Allow showposts through?
				raise(ValueError("Special searches take no parameters."))
			
			if special == "today":
				search_page = "search.php?do=getdaily"
			elif special == "new":
				search_page = "search.php?do=getnew"
			else:
				raise(ValueError("Invalid special search."))
		else:
			ret = forumapi.hooks.run("vb_get_search_results_page", **locals())
			if ret:
				search_page = ret
			elif (list(kw.keys()) == ["user"] or list(kw.keys()) == ["user", "starteronly"]) and len(a) == 0:
				search_data["u"] = User.obj(self, kw["user"]).id
				
				if "starteronly" in kw and kw["starteronly"]:
					search_data["starteronly"] = "1"
				else:
					callback = self.get_search_posts_list
					search_data["starteronly"] = "0"
				
				search_page = "search.php?do=finduser"
			else:
				# TODO: Might want to implement human verification
				# That means we'll have to use Form. I might want to anyway.
				search_page = "search.php?do=process"
				search_data = {
					"securitytoken": self.get_security_token(),
					"do": "process",
				}
				method = "post"
				
				if len(a) > 0:
					search_data["query"] = " ".join(a)
				
				if "user" in kw:
					search_data["searchuser"] = User.obj(self, kw["user"]).name
					
					if "thread" not in kw:
						if "starteronly" in kw and not kw["starteronly"]:
							search_data["starteronly"] = "0"
						else:
							search_data["starteronly"] = "1"
					
					search_data["exactname"] = "1"
				
				if "thread" in kw:
					search_data["searchthreadid"] = Thread.obj(self, kw["thread"]).id
				else:
					if "titleonly" in kw and kw["titleonly"]:
						search_data["titeonly"] = "1"
					else:
						search_data["titleonly"] = "0"
					
					if "prefixes" in kw:
						search_data["prefixchoice[]"] = kw["prefixes"]
					
					if "minposts" in kw:
						search_data["replyless"] = "0"
						search_data["replylimit"] = int(kw["minposts"])
					elif "maxposts" in kw:
						search_data["replyless"] = "1"
						search_data["replylimit"] = int(kw["maxposts"])
					
					if "showposts" in kw and kw["showposts"]:
						callback = self.get_search_posts_list
						search_data["showposts"] = "1"
					else:
						search_data["showposts"] = "0"
					
					if "forums" in kw:
						search_data["forumchoice[]"] = [Forum.obj(self, forum).id for forum in kw["forums"]]
						if "subforums" in kw and not kw["subforums"]:
							search_data["subforums"] = "0"
						else:
							search_data["subforums"] = "1"
				
				forumapi.hooks.run("vb_get_search_results", **locals())
				
				search_data["dosearch"] = "Search Now"
				search_data["saveprefs"] = "0"
		
		response = self.get_page(search_page, post_data=search_data, method=method)
		return(self.get_pages(callback, response=response, method=method, pages=pages, start=start, t=t, p=p))
	
	def get_pages(self, callback, page=None, response=None, post_data={}, method="get", pages=1, start=1, all_pages=None, **kw):
		"""Given a URL of a page or a `PageRequest` of a page, retrieves more
		pages depending on the value of the `pages` parameter. By default, only
		the first page is used (since it requires additional requests to get
		more), but this can be modified to any positive integer or simply "all".
		
		The contents of every page is sent as a `BeautifulSoup` to a callback.
		This callback may add items to a list, which this function will finally
		return."""
		
		if method == "get":
			query = post_data
		else:
			query = {}
		
		if not (response or page):
			raise(ValueError("Page or response required."))
		
		if response and not page:
			page = response.response.geturl()
		
		result = PagedResult(self, page, callback, query=query, method=method, pages=all_pages)
		result.get_pages(response=response, pages=pages, start=start, **kw)
		
		return(result)
	
	def get_threads(self, page=None, response=None, pages=None, start=1, f=None, t=None):
		"""Given a `PageRequest` of a page with a threads list, obtains a list
		of threads on that page.
		
		The `pages` parameter can be used to modify the limit on the number of
		pages. By default, only the first page of the list is returned (since
		it requires additional requests to get more), but this can be modified
		to any positive integer or simply "all".
		
		If this is a threads list on a forum display, the `f` parameter should
		be set to its `Forum`. The `t` parameter can be set to a `Thread` that
		is to be found in the threads list. It will continue the search until
		the thread is found or the number of pages is exhausted.
		
		Returns a list of `Thread` objects."""
		
		if pages is None:
			if t is not None:
				pages = "all"
			else:
				pages = 1
		
		return(self.get_pages(self.get_threads_list, page=page, response=response, pages=pages, start=start, f=f, t=t))
	
	_onclick_pattern = None
	
	@property
	def onclick_pattern(self):
		if self._onclick_pattern:
			return(self._onclick_pattern)
		else:
			self.__class__._onclick_pattern = re.compile(r"^window.*u=([0-9]+)")
			return(self._onclick_pattern)
	
	def get_threads_list(self, result, data, page_soup, first_page=False, f=None, t=None, **kw):
		"""Given a `BeautifulSoup` of a page and a list, adds `Thread` objects
		to that list representing the threads on the page."""
		
		if first_page:
			if t and t in result.items:
				return(False)
			
			if f:
				self.retrieve_metadata(soup=page_soup, f=f, t=t, loc="forum")
				
				f._subforums = self.get_forums_list(soup=page_soup, f=f, loc="forum")
				f._sticky_threads = []
		
		table_soup = page_soup.find(id="threadslist")
		
		if not table_soup:
			return(False)
		
		continue_pages = True
		
		for row in table_soup.findAll("tr"):
			cells = row.findAll("td")
			
			if cells[0]["class"] == "alt1" and len(cells) >= 5:
				# It's actually a thread
				
				status_info = self.parse_statusicon(cells[0].find("img"))
				
				posticon = cells[1].find("img")
				if posticon:
					posticon_url = posticon["src"]
				else:
					posticon_url = ""
				
				thread_link = cells[2].find("a", href=lambda url: url and "showthread.php?t=" in url)
				
				thread_id = int(forumapi.qs_dict_from_url(thread_link["href"])["t"][0])
				
				deleted_title = cells[2].find("em")
				if deleted_title:
					deleted = True
					thread_title = str(deleted_title.string)
				else:
					deleted = False
					thread_title = str(thread_link.string).strip()
				
				if not(status_info["moved"] or deleted or cells[2]["title"].endswith("...")):
					content = str(cells[2]["title"])
				else:
					content = None
				
				first_user_block = cells[2].findAll("div", "smallfont")[-1]
				first_user_spans = first_user_block.findAll("span")
				
				if first_user_spans:
					first_user_span = first_user_spans[-1]
					first_user_id = int(self.onclick_pattern.match(first_user_span["onclick"]).group(1))
					
					first_user_name = str(first_user_span.string)
				else:
					# Unregistered
					first_user_id = 0
					
					first_user_name = str(first_user_block.string).strip()
				
				first_user = User(self, id=first_user_id, name=first_user_name)
				
				first_post = Post(self, id=None, content=content, user=first_user)
				
				if status_info["moved"]:
					sticky = None
					
					latest_post = None
					
					posts = None
				elif deleted:
					if str(deleted_title.previousSibling.string).strip().startswith(self.lang.phrases["threadlist_sticky"]):
						sticky = True
					else:
						sticky = False
					
					latest_post = None
					
					# Trash can
					posts = self.get_number(re.match("([0-9,]+)", cells[2].find("img")["alt"]).group(1))
				else:
					if cells[2].find("img", src=lambda url: url and "sticky.gif" in url):
						sticky = True
					else:
						sticky = False
					
					latest_soup = cells[3].find("div")
					
					latest_time = self.get_datetime(latest_soup)
					
					latest_links = latest_soup.findAll("a")
					
					latest_user_name = str(latest_links[0].string)
					
					latest_post_id = int(forumapi.qs_dict_from_url(latest_links[1]["href"])["p"][0])
					
					latest_post = Post(self, id=latest_post_id, user=latest_user_name, time=latest_time, read=status_info["read"])
					
					posts = self.get_number(cells[4].find("a").string) + 1
				
				if status_info["moved"] or deleted:
					own_posts = None
				else:
					own_posts = status_info["own_posts"]
				
				if status_info["moved"]:
					forum = None
				elif f:
					forum = f
				else:
					# Seventh "forum" column is not available on forum view
					forum_link = cells[6].find("a")
					
					forum_name = str(forum_link.string)
					forum_id = int(forumapi.qs_dict_from_url(forum_link["href"])["f"][0])
					
					forum = Forum(self, id=forum_id, name=forum_name)
				
				thread = Thread(self, id=thread_id, title=thread_title, forum=forum, post_count=posts, own_posts=own_posts, first_post=first_post, latest_post=latest_post, read=status_info["read"], closed=status_info["closed"], deleted=deleted, sticky=sticky)
				
				forumapi.hooks.run("vb_get_threads_list", **locals())
				
				if thread == t:
					continue_pages = False
				
				if sticky and forum:
					if forum._sticky_threads is None:
						forum._sticky_threads = []
					if thread not in forum._sticky_threads:
						forum._sticky_threads.append(thread)
				
				if not(sticky and f):
					data.append(thread)
		
		return(continue_pages)
	
	def get_search_posts_list(self, result, data, page_soup, first_page=False, p=None, **kw):
		"""Given a search result page that has showposts=1, retrieves a list of
		posts from the page."""
		
		if first_page and p and p in result.items:
			return(False)
		
		table_soup = page_soup.find(id="threadslist")
		
		if not table_soup:
			return(False)
		
		continue_pages = True
		
		post_tables = table_soup.findNextSiblings("table", "tborder")
		
		for post_table in post_tables:
			rows = post_table.findAll("tr")
			
			if len(rows) == 2:
				# It's actually a searched post table
				
				forum_link = rows[0].find("a")
				
				forum_id = forumapi.qs_dict_from_url(forum_link["href"])["f"][0]
				forum_name = str(forum_link.string)
				
				forum = Forum(self, id=forum_id, name=forum_name)
				
				divs = rows[1].findAll("div")
				
				posts = self.get_number(divs[0].find("strong").string) + 1
				
				status_info = self.parse_statusicon(divs[1].find("img"))
				
				thread_link = divs[1].find("a")
				
				thread_id = forumapi.qs_dict_from_url(thread_link["href"])["t"][0]
				thread_title = str(thread_link.strong.string)
				
				thread = Thread(self, id=thread_id, title=thread_title, forum=forum, post_count=posts, own_posts=status_info["own_posts"], closed=status_info["closed"])
				
				user_link = divs[3].find("a")
				
				user_id = forumapi.qs_dict_from_url(user_link["href"])["u"][0]
				user_name = str(user_link.string)
				
				user = User(self, id=user_id, name=user_name)
				
				post_link = divs[4].find("a")
				
				post_id = forumapi.qs_dict_from_url(post_link["href"])["p"][0]
				post_title = str(post_link.string)
				
				if rows[1].find("img", alt="Deleted Post"):
					deleted = True
				else:
					# We can see it
					deleted = False
				
				post = Post(self, id=post_id, thread=thread, title=post_title, user=user, deleted=deleted)
				
				if posts == 1:
					thread._first_post = post
					post._count = 1
				
				if not user._posts:
					user._posts = [post]
				
				if post == p:
					continue_pages = False
				
				data.append(post)
		
		return(continue_pages)
	
	def get_users(self, pages=None, start=1, prefix=None, sort=None, order="asc", u=None, **kw):
		"""Gets a list of users from a members list.
		
		`prefix` can be used to only receive users whose name start with a
		certain	letter (case insensitive, use `#` for all names not starting
		with a letter). `sort` can be used to sort on a different column.
		
		Can also be used to search for a certain user, although it is probably
		better to use `get_profile()` or anything for that.
		
		Any remaining parameters will be used for an advanced search."""
		
		method = "get"
		data = {
			"ltr": prefix,
			"sort": sort,
			"order": order,
		}
		if kw:
			#method = "post"
			data["do"] = "search"
			data.update(kw)
		
		return(self.get_pages(self.get_users_list, page="memberlist.php", method=method, post_data=data, pages=pages, start=start, u=u))
	
	def get_users_list(self, result, data, page_soup, first_page=False, u=None, **kw):
		"""Given a members list search result page, retrieves a list of users
		from the page."""
		
		if first_page and u and u in self.items:
			return(False)
		
		form_soup = page_soup.find("form", action=lambda url: "do=deleteusergroups" in url)
		
		if not form_soup:
			return(False)
		
		#self.get_security_token(soup=form_soup)
		
		table_soup = form_soup.findAll("table", "tborder")[1]
		
		continue_pages = True
		
		first_row = True
		headers = []
		
		for row in table_soup.findAll("tr"):
			cells = row.findAll("td")
			
			if first_row:
				for cell in cells:
					if cell.string:
						headers.append(str(cell.string).lower())
					else:
						headers.append(forumapi.qs_dict_from_url(cell.find("a")["href"])["sort"][0])
				
				first_row = False
			elif len(cells) == 5:
				user = User(self, id=int(cells[0]["id"][1:]))
				
				idx = 0
				for cell in cells:
					if headers[idx] == "username":
						user_link = cell.find("a")
						user.retrieve_name(user_link)
						
						user._title = forumapi.remove_tags(cell.find("div", "smallfont"))
					elif headers[idx] == "joindate":
						user._join_date = self.get_datetime(str(cell.string))
					elif headers[idx] == "posts":
						user._post_count = self.get_number(cell.string)
					elif headers[idx] == "avatar":
						avatar = cell.find("img")
						if avatar:
							if user._avatar is None:
								user._avatar = forumapi.File(avatar["src"], session=self)
						else:
							user._avatar = False
					
					idx += 1
				
				if u == user:
					continue_pages = False
				
				data.append(user)
		
		return(continue_pages)
	
	def get_forums_list(self, page=None, soup=None, loc=None, f=None, **kw):
		"""Retrieves a list of forums from a page.
		
		Returns a list of `Forum` objects representing the forums that are one
		level below the current forum."""
		
		if f is None:
			# Set to False so forum parent becomes false
			f = False
		else:
			f = Forum.obj(self, f)
		
		if soup is None:
			if page is None:
				if loc == "index":
					page = "index.php"
				elif loc == "forum" and f:
					page = "forumdisplay.php?f={0}".format(f.id)
				else:
					raise(ValueError("At least one of page, soup or loc required."))
			
			soup = self.soup(self.get_page(page))
		
		if loc == "index":
			forums = soup.findAll("table", "tborder")[1]
		elif loc == "forum":
			search_subforums = soup.find(id="forumsearch.subforums")
			if search_subforums:
				forums = search_subforums.parent.parent.nextSibling.nextSibling
			else:
				# No subforums
				return([])
		else:
			raise(forumapi.UnknownError("Unknown page location."))
		
		result = []
		
		# Last processed forum with offset <= 0 (-1 = divider, 0 = forum)
		forum = None
		
		for tbody in forums.findAll("tbody"):
			for row in tbody.findAll("tr", recursive=False):
				cells = row.findAll("td", recursive=False)
				
				if cells[0]["class"] == "tfoot":
					# Footer
					continue
				elif cells[0]["class"] == "tcat":
					# Divider forum
					offset = -1
					
					forum_link = cells[0].find("a", href=lambda url: url and "forumdisplay.php" in url)
					
					forum_name = str(forum_link.string)
					
					forum_id = int(forumapi.qs_dict_from_url(forum_link["href"])["f"][0])
					
					# Will be filled by later forums when they are processed
					subforums = []
				else:
					if cells[0]["class"] == "alt1Active":
						# Forum
						offset = 0
					else:
						# Subforum of the previous forum in the table
						offset = 1
					
					forum_links = cells[offset].findAll("a", href=lambda url: url and "forumdisplay.php" in url)
					
					forum_name = str(forum_links[0].strong.string)
					
					link_qs = forumapi.qs_dict_from_url(forum_links[0]["href"])
					forum_id = int(link_qs["f"][0])
					
					subforums = []
					for link in forum_links[1:]:
						# Inline subforum links
						if link.previousSibling.previousSibling["id"].startswith("forum_statusicon_"):
							subforum = Forum(self, id=int(forumapi.qs_dict_from_url(link["href"])["f"][0]), name=str(link.string), parent=forum_id)
							if subforum not in subforums:
								subforums.append(subforum)
				
				if offset > 0:
					subforum = Forum(self, id=forum_id, name=forum_name, parent=forum, subforums=subforums)
					
					# We know that forum._subforums is already a list because we
					# set it to be so when it was processed
					if forum is not None and subforum not in forum._subforums:
						forum._subforums.append(subforum)
				else:
					forum = Forum(self, id=forum_id, name=forum_name, subforums=subforums)
					forum._parent = f
					
					result.append(forum)
		
		return(result)
	
	def retrieve_metadata(self, page=None, soup=None, loc=None, f=None, t=None, p=None, **kw):
		"""Reads a page and obtains some data, such as navigation, from it.
		
		This function can be given either a page to request, or a
		`BeautifulSoup` to use. If neither is given, a forum or thread will be
		requested. The data will be added to the forum, thread and post, if
		applicable. The parameter `loc` should be used to denote the page 
		location if a page or soup is given. Currently recognized locations are 
		"forum", "thread" and "postform"."""
		
		p = Post.obj(self, p)
		if p and p._thread:
			t = p._thread
		
		t = Thread.obj(self, t)
		
		f = Forum.obj(self, f)
		
		if soup is None:
			if page is None:
				if t:
					page = "showthread.php?t={0}".format(t.id)
					loc = "thread"
				elif f:
					page = "forumdisplay.php?f={0}".format(f.id)
					loc = "forum"
				else:
					ret = forumapi.hooks.run("vb_retrieve_metadata_page", **locals())
					if ret:
						page = ret[0]
						loc = ret[1]
					else:
						raise(ValueError("Forum, thread or post with thread required if page and soup not given."))
			
			soup = self.soup(self.get_page(page))
		
		breadcrumb = self.get_breadcrumb(soup, f=f, t=t, p=p, loc=loc, **kw)
		
		current = str(soup.find("td", "navbar").find("strong").string).strip()
		
		if t:
			if loc == "thread":
				t._title = current
				if len(breadcrumb) > 0 and isinstance(breadcrumb[-1], Forum):
					t._forum = breadcrumb[-1]
			elif loc == "postform":
				if len(breadcrumb) > 1 and isinstance(breadcrumb[-2], Forum):
					t._forum = breadcrumb[-2]
			
			if t._forum:
				f = t._forum
			
			if p and not p._thread:
				p._thread = t
		
		if f and loc == "forum":
			f._name = current
			if len(breadcrumb) > 0:
				f._parent = breadcrumb[-1]
			else:
				f._parent = False
		
		forumapi.hooks.run("vb_retrieve_metadata", **locals())
		
		self.retrieve_powers(soup)
	
	def get_breadcrumb(self, soup, f=None, t=None, p=None, loc=None, **kw):
		"""Given a `BeautifulSoup`, extracts the breadcrumb from it and returns
		a list of `Forum`, `Thread`, and/or `Post` objects."""
		
		breadcrumb = []
		
		for navbar in soup.findAll("span", "navbar"):
			link = navbar.find("a")
			if not link:
				continue
			
			link_qs = forumapi.qs_dict_from_url(link["href"])
			
			identifier = None
			name = str(link.string).strip()
			
			if "f" in link_qs:
				if f is not None and not f._name and loc != "forum":
					f._name = name
					breadcrumb.append(f)
				else:
					identifier = "f"
					cls = Forum
			elif "t" in link_qs:
				if t:
					t._title = name
					breadcrumb.append(t)
				else:
					identifier = "t"
					cls = Thread
			elif "p" in link_qs:
				# Breadcrumb never links to indivual posts but to threads, using
				# a post ID for direct linking.
				
				if t:
					t._title = name
					breadcrumb.append(t)
				else:
					breadcrumb.append(Thread(self, title=name))
				
				#post_id = int(link_qs["p"][0])
				#if p:
				#	p._id = post_id
				#	breadcrumb.append(p)
				#else:
				#	breadcrumb.append(Post(self, id=post_id))
			else:
				ret = forumapi.hooks.run("vb_get_breadcrumb", **locals())
				if ret:
					identifier = ret[0]
					cls = ret[1]
			
			if identifier:
				id = int(link_qs[identifier][0])
				
				breadcrumb.append(cls(self, id, name))
		
		return(breadcrumb)
	
	def retrieve_powers(self, soup):
		"""Given a `BeautifulSoup`, searches the page for links to the Admin CP
		and Mod CP, and sets powers to the session user."""
		
		if self.user._powers is None:
			self.user._powers = []
		
		# Footer is something that forums often change, but this should suffice
		footer_soup = soup.findAll("td", "tfoot")[-1]
		
		for link in footer_soup.findAll("a"):
			if link.string == self.lang.phrases["footer_admincp"]:
				if "admin" not in self.user._powers:
					self.user._powers.append("admin")
			elif link.string == self.lang.phrases["footer_modcp"]:
				if "mod" not in self.user._powers:
					self.user._powers.append("mod")
	
	def prepare_post(self, t=None, p=None):
		"""Prepares to make a post in reply to the given thread or post.
		
		This returns a `PreparedPost` with the default title and message
		(including the quote if it's a reply to a post) set."""
		
		if (t and p) or not (t or p):
			raise(ValueError("Thread xor post required."))
		
		thread = None
		post = None
		
		if t:
			thread = Thread.obj(self, t)
			t = thread.id
				
			if thread._latest_post:
				post = thread._latest_post
			
			identifier = "t", t
		else:
			post = Post.obj(self, p)
			p = post.id
			
			if post._thread:
				thread = post._thread
			
			identifier = "p", p
		
		form_url = "newreply.php?do=newreply&{0}={1}".format(*identifier)
		
		form_page = self.get_page(form_url)
		
		soup = self.soup(form_page)
		
		form_soup = soup.find("form", action=lambda url: "do=postreply" in url)
		if not form_soup:
			raise(forumapi.PermissionError(self.get_error(soup) or "Reply form not found."))
		
		if not thread:
			thread_link = form_soup.find("td", "tcat").find("a")
			thread = Thread(self, id=int(forumapi.qs_dict_from_url(thread_link["href"])["t"][0]), title=str(thread_link.string))
			
			post._thread = thread
		
		self.retrieve_metadata(soup=soup, loc="postform", t=thread, p=post)
		
		post_data = Form(form_soup, session=self)
		
		if thread and not thread._title and post_data["title"].startswith("Re: "):
			thread._title = post_data["title"][4:]
		
		post_info = self.parse_quote(post_data["message"])
		
		if post_info:
			post = Post(self, id=post_info["post_id"], thread=thread)
			
			if not post._user:
				post._user = User(self, name=post_info["username"], posts=[post])
			elif not post._user._name:
				post._user._name = post_info["username"]
			
			post._content = post_info["content"]
		
		# Thread review
		review_soup = soup.find("div", id="collapseobj_threadreview").find("table")
		
		posts = []
		lastest_post = True
		
		for post_row in review_soup.findAll("tr", recursive=False):
			cells = post_row.findAll("td")
			
			if len(cells) == 2:
				id = int(re.match("Post ([0-9]+)", post_row["title"]).group(1))
				
				a_post = Post(self, id=id, thread=thread)
				if lastest_post:
					lastest_post = False
					if thread:
						thread._latest_post = a_post
				
				if not a_post._user:
					a_post._user = User(self, name=str(cells[0].string), posts=[a_post])
				elif not post._user._name:
					a_post._user._name = str(cells[0].string)
				
				if not a_post._content:
					a_post._content = BBCode(self, cells[1])
				
				posts[0:0] = [a_post]
		
		# TODO: Doesn't work well with PagedResult and maybe makes a request
		if thread and posts:
			if thread._posts is None:
				# ...
				thread._posts = posts
			else:
				thread._posts.merge(posts)
		
		return(PreparedPost(self, "postreply", identifier, post_data, t=thread))
	
	def make_post(self, message, title=None, t=None, p=None, prepend_quote=False, *a, **kw):
		"""Creates a new post in a single step.
		
		This function takes a message body and either (but not both) `t` or `p`
		parameters, which can either be integer ids or respectively `Thread`
		and `Post` objects. It can also set a title to override the default,
		and can prepend a quote if the `p` parameter is given.
		
		If successful, returns a `Post` object of the newly-created post."""
		
		post = self.prepare_post(t=t, p=p)
		
		if prepend_quote:
			post.message += message
		else:
			post.message = message
		
		post.title = title
		
		return(post.submit())
	
	def prepare_thread(self, forum, *a, **kw):
		"""Prepares to create a new thread in a given forum. Returns a
		`PreparedPost` that needs its `.message` and `.title` properties set."""
		
		forum = Forum.obj(self, forum)
		
		identifier = "f", forum.id
		
		form_url = "newthread.php?do=newthread&{0}={1}".format(*identifier)
		
		form_page = self.get_page(form_url)
		
		soup = self.soup(form_page)
		
		form_soup = soup.find("form", action=lambda url: "do=postthread" in url)
		if not form_soup:
			raise(forumapi.PermissionError(self.get_error(soup) or "Thread form not found."))
		
		post_data = Form(form_soup, session=self)
		
		forumapi.hooks.run("vb_prepare_thread", **locals())
		
		return(PreparedPost(self, "createthread", identifier, post_data, f=forum, **kw))
	
	def make_thread(self, message, title, forum, *a, **kw):
		"""Creates a new thread in a given forum, returning a `Thread` object of
		the newly-created thread."""
		
		post = self.prepare_thread(forum, *a, **kw)
		
		post.message = message
		post.title = title
		
		return(post.submit())
	
	_own_posts_pattern = None
	
	@property
	def own_posts_pattern(self):
		if self._own_posts_pattern:
			return(self._own_posts_pattern)
		else:
			self.__class__._own_posts_pattern = re.compile(self.lang.phrases["threadlist_own_posts"])
			return(self._own_posts_pattern)
	
	def parse_statusicon(self, statusicon):
		"""Given a `BeautifulSoup` of a thread statusicon, returns a dictionary
		of "read", "moved", "locked" and "own_posts"."""
		
		attrs = dict(statusicon.attrs)
		
		info = {
			"read": None,
			"moved": None,
			"closed": None,
			"own_posts": None,
		}
		
		if "_new" in attrs["src"]:
			info["read"] = False
		else:
			info["read"] = True
		
		if "_moved" in attrs["src"]:
			info["moved"] = True
		else:
			info["moved"] = False
		
		if "_lock" in statusicon["src"]:
			info["closed"] = True
		else:
			info["closed"] = False
		
		if "alt" in attrs:
			match = self.own_posts_pattern.match(attrs["alt"])
			if match:
				info["own_posts"] = self.get_number(match.group(1))
		
		return(info)
	
	_quote_pattern = None
	
	@property
	def quote_pattern(self):
		if self._quote_pattern:
			return(self._quote_pattern)
		else:
			self.__class__._quote_pattern = re.compile(r"^\s*\[quote=(?P<name>[^\]]+)(?:;(?P<id>[0-9]+))\](?P<content>.*)\[/quote\]\s*$", re.I + re.DOTALL)
			return(self._quote_pattern)
	
	def parse_quote(self, bbcode):
		"""Parses a string containing a bbcode `[quote]`, returning a dictionary
		of "username", "post_id" and "content"."""
		
		match = self.quote_pattern.match(bbcode)
		
		if match:
			quote_info = {
				"username": str(match.group("name")),
				"content": str(match.group("content")),
			}
			
			if "id" in match.groupdict():
				quote_info["post_id"] = int(match.group("id"))
			
			return(quote_info)
		else:
			return(None)
	
	_postbit_edit_pattern = None
	
	@property
	def postbit_edit_pattern(self):
		if (self._postbit_edit_pattern):
			return(self._postbit_edit_pattern)
		else:
			self.__class__._postbit_edit_pattern = re.compile(self.lang.phrases["postbit_editline"])
			return(self._postbit_edit_pattern)
	
	def parse_postbit(self, soup):
		"""Parses a post or PM postbit, returning a dictionary of "id", "user",
		"time", "title", "content", "deleted" and "read"."""
		
		deleted = False
		
		post_id = re.match("^post([0-9]*)$", soup["id"]).group(1)
		if post_id:
			id = int(post_id)
		else:
			# Private messages have no ID in the postbit
			id = ""
		
		rows = soup.findAll("tr", recursive=False)
		
		content_cell = soup.find(id="td_post_{0}".format(id))
		
		postcount = soup.find(id="postcount{0}".format(id))
		if postcount:
			count = int(str(postcount.find("strong").string))
		else:
			count = 0
		
		if not content_cell:
			# Deleted post
			deleted = True
			title = ""
		else:
			title = str(content_cell.find("strong").string).strip()
		
		user_cell = rows[1].find("td", "alt2")
		
		if deleted:
			user_link = user_cell.findAll("a")[-1]
		else:
			user_info = user_cell.find(id="postmenu_{0}".format(post_id))
			user_link = user_info.find("a") # bigusername
		
		if user_link:
			user = User(self, id=int(forumapi.qs_dict_from_url(user_link["href"])["u"][0]))
			
			user.retrieve_name(user_link)
		else:
			# Unregistered
			user = User(self, id=0, name=str(user_info.string).strip())
		
		if deleted:
			# Probably could fetch some of this in showpost (if privileged)?
			read = False
			time = None
			content = ""
			edit = None
			attachments = []
		else:
			user_bits = user_cell.findAll("div", "smallfont")
			
			user._title = forumapi.remove_tags(user_bits[0])
			
			user_avatar = user_cell.find("img", {"src": lambda url: "image.php" in url})
			if user_avatar:
				if user._avatar is None:
					user._avatar = forumapi.File(user_avatar["src"], session=self)
			else:
				user._avatar = False
			
			for user_data in user_bits[-1].findAll("div"):
				if user_data.string and ": " in user_data.string:
					(name, value) = user_data.string.split(": ", 1)
					if name == self.lang.phrases["postbit_user_join_date"]:
						if not user._join_date:
							user._join_date = self.get_datetime(value, format="join_date_format")
						pass
					elif name == self.lang.phrases["postbit_user_posts"]:
						user._post_count = self.get_number(value)
			
			statusicon = soup.find("a", {"name": "post{0}".format(post_id)})
			if "_old" in statusicon.img["src"]:
				read = True
			else:
				read = False
			
			time = self.get_datetime(statusicon.nextSibling.string)
			
			message = content_cell.find(id="post_message_{0}".format(post_id))
			content = BBCode(self, message)
			attachments = []
			
			try:
				edit_soup = content_cell.findAll("em")[-1]
				
				edit_line = forumapi.remove_tags(edit_soup).strip()
				
				match = self.postbit_edit_pattern.match(edit_line)
				
				edit = {
					"user": User(self, name=match.group("user")),
					"time": self.get_datetime([match.group("date"), match.group("time")]),
					"reason": match.group("reason"),
				}
			except:
				# No edit
				edit = None
		
		forumapi.hooks.run("vb_parse_postbit", **locals())
		
		return({
			"id": id,
			"count": count,
			"user": user,
			"time": time,
			"title": title,
			"content": content,
			"edit": edit,
			"deleted": deleted,
			"read": read,
			"attachments": attachments,
		})

class User(forumapi.User):
	"""A user. It can know the user's ID and name."""
	
	@classmethod
	def obj(cls, session, *a, **kw):
		if isinstance(a[0], cls):
			return(a[0])
		elif isinstance(a[0], int):
			return(cls(session, *a[1:], id=a[0], **kw))
		elif isinstance(a[0], str):
			return(cls(session, *a[1:], name=a[0], **kw))
		else:
			return(None)
	
	def __init__(self, session, id=None, name=None, title=None, posts=None, threads=None, powers=None, join_date=None, birth_date=None, post_count=None, public_groups=None, visitor_messages=None, friends=None):
		if id is None and name is None:
			raise(ValueError("ID or name required."))
		
		self.session = session
		
		self._id = id
		self._name = name
		
		self._title = title
		
		if isinstance(posts, list):
			self._posts = [Post.obj(session, post) for post in posts]
		else:
			self._posts = posts
		
		if isinstance(threads, list):
			self._threads = [Thread.obj(session, thread) for thread in threads]
		else:
			self._threads = threads
		
		self._powers = powers
		
		self._join_date = join_date
		self._birth_date = birth_date
		
		if post_count is not None:
			self._post_count = int(post_count)
		else:
			self._post_count = None
		
		self._public_groups = public_groups
		
		if isinstance(visitor_messages, list):
			self._visitor_messages = [self.module.get_attr("VisitorMessage").VisitorMessage.obj(session, vm) for vm in visitor_messages]
		else:
			self._visitor_messages = visitor_messages
		
		if isinstance(friends, list):
			self._friends = [User.obj(session, user) for user in friends]
		else:
			self._friends = friends
		
		self._avatar = None
		
		self._find_instances()
	
	def _merge(self, other):
		if self._id:
			other._id = self._id
		if self._name:
			other._name = self._name
		
		if self._title is not None:
			other._title = self._title
		
		self._merge_list(other, "_powers")
		
		self._merge_obj(other, "_posts")
		self._merge_obj(other, "_threads")
		
		if self._join_date is not None:
			other._join_date = self._join_date
		
		if self._birth_date is not None:
			other._birth_date = self._birth_date
		
		if self._post_count is not None:
			other._post_count = self._post_count
		
		self._merge_list(other, "_public_groups")
		
		self._merge_obj(other, "_visitor_messages")
		self._merge_obj(other, "_friends")
		self._merge_obj(other, "_avatar")
	
	@property
	def data(self):
		return({
			"id": self._id,
			"name": self._name,
			"title": self._title,
			"powers": self._powers,
			"posts": forumapi._flatten_data(self._posts),
			"threads": forumapi._flatten_data(self._threads),
			"join_date": self._join_date,
			"birth_date": self._birth_date,
			"post_count": self._post_count,
			"public_groups": self._public_groups,
			"visitor_messages": forumapi._flatten_data(self._visitor_messages),
			"friends": forumapi._flatten_data(self._friends),
		})
	
	@property
	def id(self):
		if self._id is not None:
			return(self._id)
		else:
			ajax_data = {
				"securitytoken": self.session.get_security_token(),
				"do": "usersearch",
				"fragment": self._name,
			}
			
			ajax_page = self.session.get_page("ajax.php?do=usersearch", post_data=ajax_data)
			
			xml_soup = self.session.soup(ajax_page, "xml")
			user = xml_soup.find("user")
			if user:
				self._id = int(user["userid"])
				
				self._find_instances(init=False)
				
				return(self._id)
			else:
				raise(forumapi.UserError("User does not exist."))
	
	@property
	def name(self):
		if self._name:
			return(self._name)
		else:
			if self._posts:
				# Preparing post gives other useful data
				self.session.prepare_post(p=self.posts[0])
			else:
				self.get_profile()
			
			self._find_instances(init=False)
			
			return(self._name)
	
	@property
	def title(self):
		if self._title is not None:
			return(self._title)
		else:
			self.get_profile()
			return(self._title)
	
	@property
	def powers(self):
		if self._powers is not None:
			return(self._powers)
		else:
			self.get_profile()
			return(self._powers)
	
	@property
	def posts(self):
		if self._posts is not None:
			return(self._posts)
		else:
			self.retrieve_posts()
			return(self._posts)
	
	@property
	def threads(self):
		if self._threads is not None:
			return(self._threads)
		else:
			self.retrieve_threads()
			return(self._threads)
	
	@property
	def join_date(self):
		if self._join_date is not None:
			return(self._join_date)
		else:
			self.get_profile()
			return(self._join_date)
	
	@property
	def join_date(self):
		if self._birth_date is not None:
			return(self._birth_date)
		else:
			self.get_profile()
			return(self._birth_date)
	
	@property
	def post_count(self):
		if self._post_count is not None:
			return(self._post_count)
		else:
			self.get_profile()
			return(self._post_count)
	
	@property
	def public_groups(self):
		if self._public_groups is not None:
			return(self._public_groups)
		else:
			self.get_profile()
			return(self._public_groups)
	
	@property
	def visitor_messages(self):
		if self._visitor_messages is not None:
			return(self._visitor_messages)
		else:
			self.module.get_attr("VisitorMessage")
			self.get_profile()
			return(self._visitor_messages)
	
	@property
	def friends(self):
		if self._friends is not None:
			return(self._friends)
		else:
			self.get_profile()
			return(self._friends)
	
	@property
	def avatar(self):
		if self._avatar is not None:
			return(self._avatar)
		else:
			self.get_profile()
			return(self._avatar)
	
	def get_profile(self, friends_pages=None, friend=None, **kw):
		"""Retrieves the user's profile page and sets data from it."""
		
		query = {"u": self.id}
		forumapi.hooks.run("vb_user_profile_query", **locals())
		
		page = self.session.get_page("member.php", post_data=query, method="get")
		
		page_soup = self.session.soup(page)
		
		self.session.retrieve_powers(page_soup)
		
		profile_soup = page_soup.find(id="usercss")
		if not profile_soup:
			raise(forumapi.UserError("User has no profile."))
		
		username_box = profile_soup.find(id="username_box")
		
		header = username_box.find("h1")
		
		self.retrieve_name(header)
		
		usertitle = username_box.find("h2")
		
		self._title = forumapi.remove_tags(usertitle)
		
		self._join_date = False
		self._birth_date = False
		self._post_count = 0
		
		stats = profile_soup.find(id="stats_mini").find("dl")
		for stats_type in stats.findAll("dt"):
			stats_data = stats_type.nextSibling.nextSibling
			type = str(stats_type.string)
			if type == self.session.lang.phrases["profile_birth_date"]:
				self._birth_date = self.session.get_date(stats_data.string, formats=["birthday_year_date_format", "birthday_no_year_date_format"])
			elif type == self.session.lang.phrases["profile_join_date"]:
				# Uses the usual "date_format"
				self._join_date = self.session.get_date(stats_data.string)
			elif type == self.session.lang.phrases["profile_post_count"]:
				self._post_count = self.session.get_number(stats_data.string)
		
		public_usergroups = profile_soup.find(id="public_usergroup_list")
		if public_usergroups:
			self._public_groups = [str(group.string) for group in public_usergroups.findAll("li")]
		else:
			self._public_groups = []
		
		friends_tab = profile_soup.find(id="friends")
		if friends_tab:
			if self._friends is None:
				friends_query = query.copy()
				friends_query["tab"] = "friends"
				
				self._friends = self.session.get_pages(self.get_friends_list, response=page, post_data=friends_query, method="get", pages=friends_pages, u=friend)
			else:
				self._friends.get_pages(self.get_friends_list, response=page, pages=friends_pages, u=friend)
		
		if self._avatar is None:
			avatar = profile_soup.find(id="user_avatar")
			if avatar:
				self._avatar = forumapi.File("image.php?u={0}".format(self.id), session=self.session)
			else:
				self._avatar = False
		
		forumapi.hooks.run("vb_user_profile", **locals())
	
	def get_friends_list(self, result, data, page_soup, first_page=False, u=None, **kw):
		"""Given a `BeautifulSoup` of a page and a list, adds `User` objects to
		that list representing the friends on the page."""
		
		if first_page and u and u in result.items:
			return(False)
		
		friends_soup = page_soup.find(id="friends")
		
		if not friends_soup:
			return(False)
		
		pager = friends_soup.find("div", "pagenav")
		result.get_pager(pager)
		
		list_soup = friends_soup.find(id="friends_list_big")
		
		continue_pages = True
		
		for item in list_soup.findAll("li", recursive=False):
			info = item.find("div", "tborder info_bar")
			
			info_items = info.findAll("li")
			
			user_link = info_items[0].find("a")
			
			user_id = int(forumapi.qs_dict_from_url(user_link["href"])["u"][0])
			
			user_title = forumapi.remove_tags(info_items[1])
			
			user = User(self.session, id=user_id, title=user_title)
			user.retrieve_name(user_link)
			
			if u == user:
				continue_pages = False
			
			data.append(user)
		
		return(continue_pages)
	
	_class_pattern = None
	
	@property
	def class_pattern(self):
		if self._class_pattern:
			return(self._class_pattern)
		else:
			self.__class__._class_pattern = re.compile(r"^user-(.+)$")
			return(self._class_pattern)
	
	def retrieve_name(self, soup):
		"""Given a `BeautifulSoup` of a link or text with the user's name,
		retrieves the user's name and possibly their power from it.
		
		Currently only employs the way TS specifies user powers, by looking at
		a span in the soup with a class that specifies the power."""
		
		self._name = forumapi.remove_tags(soup).strip()
		
		if self._powers is None:
			self._powers = []
		
		span = soup.find("span")
		if span:
			# Power classes as they work on TS
			# TODO: This is special-casing, move to a hook...
			attrs = dict(span.attrs)
			if "class" in attrs:
				user_class = self.class_pattern.match(attrs["class"]).group(1)
				
				if user_class and user_class not in self._powers:
					self._powers.append(user_class)
	
	def retrieve_posts(self, pages=None, start=1, p=None):
		"""Obtains the posts that were made by the user.
		
		By default only retrieves the first page of the results; more pages can
		be retrieved using the `pages` parameter or a `p` parameter representing
		a Post that is to be found."""
		
		if self._posts is None:
			self._posts = self.session.get_search_results(user=self, pages=pages, start=start, p=p)
		else:
			self._posts.get_pages(pages=pages, start=start, p=p)
	
	def retrieve_threads(self, pages=None, start=1, t=None):
		"""Obtains the threads that were started by the user.
		
		By default only retrieves the first page of the results; more pages can
		be retrieved using the `pages` parameter or a `t` parameter representing
		a Thread that is to be found."""
		
		if self._threads is None:
			self._threads = self.session.get_search_results(user=self, pages=pages, start=start, t=t)
		else:
			self._threads.get_pages(pages=pages, start=start, t=t)
	
	def __eq__(self, other):
		if isinstance(other, User):
			# id -> name conversion is more expensive than name -> id (HTML/XML)
			if self._name and other._name:
				return(self.name == other.name)
			elif self._id and other._id:
				return(self.id == other.id)
			else:
				return(False)
		elif isinstance(other, int):
			return(self.id == other)
		elif isinstance(other, str):
			return(self.name == other)
		else:
			return(False)
	
	def __str__(self):
		return(self.name)
	
	def __int__(self):
		return(self.id)
	
	def __repr__(self):
		return("<vbulletin.User {self.id} \"{self.name}\">".format(self=self))

class Post(forumapi.Post):
	"""A post. It knows its ID and thread, and is able to figure out its
	contents and the user that posted it if they're not given."""
	
	@classmethod
	def obj(cls, session, *a, **kw):
		if isinstance(a[0], cls):
			return(a[0])
		elif isinstance(a[0], int):
			return(cls(session, *a[1:], id=a[0], **kw))
		else:
			return(None)
	
	def __init__(self, session, id=None, thread=None, count=None, content=None, title=None, user=None, time=None, edits=None, deleted=None, read=None, attachments=None):
		self.session = session
		
		self._id = id
		
		self._thread = Thread.obj(session, thread)
		self._count = count
		
		self._content = content
		self._title = title
		
		self._user = User.obj(session, user, posts=[self])
		
		self._time = time
		
		self._edits = edits
		
		self._deleted = deleted
		self._read = read
		
		self._attachments = attachments
		
		self._find_instances()
	
	def _merge(self, other):
		if self._id:
			other._id = self._id
		
		self._merge_obj(other, "_thread")
		if self._count:
			other._count = self._count
		
		self._merge_obj(other, "_content")
		if self._title:
			other._title = self._title
		
		if self._user:
			other._user = self._user
		
		if self._time:
			other._time = self._time
		
		self._merge_list(other, "_edits")
		
		if self._deleted is not None:
			other._deleted = self._deleted
		if self._read is not None:
			other._read = self._read
		
		self._merge_list(other, "_attachments")
	
	@property
	def data(self):
		return({
			"id": self._id,
			"thread": int(self.thread) if self._thread else None,
			"count": self._count,
			"content": forumapi._flatten_data(self._content),
			"title": self._title,
			"user": int(self.user) if self._user else None,
			"time": self._time,
			"edits": self._edits,
			"deleted": self._deleted,
			"read": self._read,
			"attachments": [int(a) for a in self.attachments] if self._attachments is not None else None,
		})
	
	@property
	def id(self):
		if self._id:
			return(self._id)
		else:
			if self._thread and self._count:
				self._id = self.thread.posts[self.count - 1]._id
				self._find_instances(init=False)
				return(self._id)
			else:
				raise(NotImplementedException("Cannot retrieve post ID."))
	
	@property
	def thread(self):
		if self._thread:
			return(self._thread)
		else:
			self.session.prepare_post(p=self)
			return(self._thread)
	
	@property
	def count(self):
		if self._count:
			return(self._count)
		else:
			self.thread.retrieve(p=self)
			return(self._count)
	
	@property
	def title(self):
		if self._title:
			return(self._title)
		else:
			if self._thread and self._count:
				self._title = self.thread.posts[self.count - 1].title
				self._find_instances(init=False)
				return(self._title)
			else:
				self.retrieve()
				return(self._title)
	
	@property
	def content(self):
		if self._content:
			return(self._content)
		else:
			self.session.prepare_post(p=self)
			return(self._content)
	
	@property
	def user(self):
		if self._user:
			return(self._user)
		else:
			self.session.prepare_post(p=self)
			return(self._user)
	
	@property
	def time(self):
		if self._time:
			return(self._time)
		else:
			self.retrieve()
			return(self._time)
	
	@property
	def edits(self):
		if self._edits:
			return(self._edits)
		else:
			# TODO: Post history
			self.retrieve()
			return(self._edits)
	
	@property
	def deleted(self):
		if self._deleted is not None:
			return(self._deleted)
		else:
			self.retrieve()
			return(self._deleted)
	
	@property
	def read(self):
		if self._read is not None:
			return(self._read)
		else:
			self.retrieve()
			return(self._read)
	
	@property
	def attachments(self):
		if self._attachments is not None:
			return(self._attachments)
		else:
			self.retrieve()
			return(self._attachments)
	
	def retrieve(self):
		"""Retrieves the single post. Raises a `PermissionError` if the post
		could not be retrieved."""
		
		query = {"p": self.id}
		if self._count:
			query["postcount"] = self.count
		
		page = self.session.get_page("showpost.php", post_data=query, method="get")
		
		soup = self.session.soup(page)
		
		post_soup = soup.find(id="post{0}".format(self.id))
		if not post_soup:
			raise(forumapi.PermissionError(self.session.get_error(soup) or "Post not found."))
		
		post_data = self.session.parse_postbit(post_soup)
		
		self._user = post_data["user"]
		self._time = post_data["time"]
		
		self._title = post_data["title"]
		self._content = post_data["content"]
		
		if post_data["edit"]:
			self._edits = [post_data["edit"]]
		else:
			self._edits = []
		
		self._deleted = post_data["deleted"]
		self._read = post_data["read"]
		
		self._attachments = post_data["attachments"]
	
	def edit(self, message, title=None, reason=None, append=False):
		"""Edits the post in a single step, optionally giving a reason and
		a new title."""
		
		post = self.prepare_edit()
		
		if title is not None:
			post.title = title
		
		if append:
			post.message += "\n\n" + message
		else:
			post.message = message
		
		if reason is not None:
			post.post_data["reason"] = reason
		
		post.submit()
	
	def prepare_edit(self):
		"""Prepares to edit the post. Returns a `PreparedPost`."""
		
		identifier = "p", self.id
		
		form_page = self.session.get_page("editpost.php?do=editpost&{0}={1}".format(*identifier))
		
		soup = self.session.soup(form_page)
		
		#self.retrieve_metadata(p=self, soup=soup, loc="editform")
		
		form_soup = soup.find("form", action=lambda url: "do=updatepost" in url)
		
		if form_soup:
			form_data = Form(form_soup, session=self.session)
		else:
			raise(forumapi.PermissionError(self.session.get_error(soup) or "Cannot find edit form."))
		
		return(PreparedPost(self.session, "editpost", identifier, form_data, p=self))
	
	def give_reputation(self, balance=True, reason=""):
		"""Adds positive or negative reputation to the user that posted the
		post, optionally giving a reason that the user will be able to see."""
		
		if balance:
			reputation = "pos"
		else:
			reputation = "neg"
		
		post_data = {
			"reputation": reputation,
			"reason": reason,
			"do": "addreputation",
			"securitytoken": self.session.get_security_token(),
			"p": self.id,
			"url": "index.php",
		}
		
		self.session.get_page("reputation.php?do=addreputation&p={0}".format(self.id), post_data=post_data)
	
	def __eq__(self, other):
		if not self._id:
			return(False)
		elif isinstance(other, Post) and other._id:
			return(self.id == other.id)
		elif isinstance(other, int):
			return(self.id == other)
		else:
			return(False)
	
	def __int__(self):
		return(self.id)
	
	def __repr__(self):
		return("<vbulletin.Post {self.id}{0}{1}>".format(" by {self.user.name}".format(self=self) if self._user else "", "{0} in t{self.thread.id}".format(" #{self.count}".format(self=self) if self._count else "", self=self) if self._thread else "", self=self))

class Thread(forumapi.Thread):
	"""A thread. It knows its ID, a latest post, the title, and the forum it's
	in. It can also know the number of posts, posts made by the session user,
	and thread tags."""
	
	@classmethod
	def obj(cls, session, *a, **kw):
		if isinstance(a[0], cls):
			return(a[0])
		elif isinstance(a[0], int):
			return(cls(session, *a[1:], id=a[0], **kw))
		elif isinstance(a[0], str):
			return(cls(session, *a[1:], title=a[0], **kw))
		else:
			return(None)
	
	def __init__(self, session, id=None, title=None, forum=None, posts=None, post_count=None, own_posts=None, first_post=None, latest_post=None, poll=None, tags=None, read=None, closed=None, deleted=None, sticky=None):
		if not (id or title):
			raise(ValueError("ID or title required."))
		
		self.session = session
		self._id = id
		
		self._title = title
		
		self._forum = Forum.obj(session, forum)
		
		if isinstance(posts, list):
			self._posts = [Post.obj(session, post) for post in posts]
		else:
			self._posts = posts
		
		if post_count is not None:
			self._post_count = int(post_count)
		else:
			self._post_count = None
		
		if own_posts is not None:
			self._own_posts = int(own_posts)
		else:
			self._own_posts = None
		
		self._first_post = Post.obj(session, first_post)
		if self._first_post:
			self._first_post._thread = self
			self._first_post._count = 1
		
		self._latest_post = Post.obj(session, latest_post)
		if self._latest_post:
			self._latest_post._thread = self
			if self._post_count is not None:
				self._latest_post._count = self._post_count
		
		if poll is not None:
			self._poll = self.module.get_attr("Poll").Poll.obj(session, poll, thread=self)
		else:
			self._poll = None
		
		if tags is not None:
			self._tags = self.module.get_attr("Tag").ThreadTags.obj(thread, tags=tags)
		else:
			self._tags = None
		
		self._read = read
		self._closed = closed
		self._deleted = deleted
		self._sticky = sticky
		
		self._find_instances()
	
	def _merge(self, other):
		if self._id:
			other._id = self._id
		
		if self._title:
			other._title = self._title
		
		if self._forum:
			other._forum = self._forum
		
		self._merge_obj(other, "_posts")
		
		if self._post_count is not None:
			other._post_count = self._post_count
		
		if self._own_posts is not None:
			other._own_posts = self._own_posts
		
		self._merge_obj(other, "_first_post")
		self._merge_obj(other, "_latest_post")
		
		if self._poll == False:
			other._poll = False
		else:
			self._merge_obj(other, "_poll")
		
		if self._tags is not None:
			other._tags = self._tags
		
		if self._read is not None:
			other._read = self._read
		if self._closed is not None:
			other._closed = self._closed
		if self._deleted is not None:
			other._deleted = self._deleted
		if self._sticky is not None:
			other._sticky = self._sticky
	
	@property
	def data(self):
		return({
			"id": self._id,
			"title": self._title,
			"forum": int(self.forum) if self._forum else None,
			"posts": forumapi._flatten_data(self._posts),
			"post_count": self._post_count,
			"own_posts": self._own_posts,
			"first_post": int(self.first_post) if self._first_post else None,
			"latest_post": int(self.latest_post) if self._latest_post else None,
			"tags": forumapi._flatten_data(self._tags),
			"read": self._read,
			"closed": self._closed,
			"deleted": self._deleted,
			"sticky": self._sticky,
		})
	
	@property
	def id(self):
		if self._id:
			return(self._id)
		elif self._forum:
			self.forum.retrieve(t=self)
			
			self._find_instances(init=False)
			
			return(self._id)
		else:
			raise(NotImplementedException("Cannot find thread ID."))
	
	@property
	def title(self):
		if self._title:
			return(self._title)
		else:
			self.session.prepare_post(t=self)
			return(self._title)
	
	@title.setter
	def title(self, value):
		self.update_title(value)
	
	@property
	def forum(self):
		if self._forum:
			return(self._forum)
		else:
			self.session.retrieve_metadata(t=self)
			return(self._forum)
	
	@property
	def posts(self):
		if self._posts is not None:
			return(self._posts)
		else:
			self.retrieve()
			return(self._posts)
	
	@property
	def post_count(self):
		if self._post_count is not None:
			return(self._post_count)
		else:
			self.forum.retrieve(t=self)
			return(self._post_count)
	
	@property
	def own_posts(self):
		if self._own_posts is not None:
			return(self._own_posts)
		else:
			self.forum.retrieve(t=self)
			return(self._own_posts)
	
	@property
	def first_post(self):
		if self._first_post:
			return(self._first_post)
		elif self._posts or not self._forum:
			self._first_post = self.posts.first
			return(self._first_post)
		else:
			self.forum.retrieve(t=self)
			return(self._first_post)
	
	@property
	def latest_post(self):
		if self._latest_post:
			return(self._latest_post)
		elif self._posts and self._posts._pages:
			self._lastest_post = self.posts.last
			return(self._lastest_post)
		else:
			self.session.prepare_post(t=self)
			return(self._latest_post)
	
	@property
	def poll(self):
		if self._poll is not None:
			return(self._poll)
		else:
			self.module.get_attr("Poll")
			self.retrieve()
			return(self._poll)
	
	@property
	def tags(self):
		if self._tags is not None:
			return(self._tags)
		else:
			self._tags = self.module.get_attr("Tag").ThreadTags(self)
			return(self._tags)
	
	@property
	def read(self):
		if self._read is not None:
			return(self._read)
		else:
			self.forum.retrieve(t=self)
			return(self._read)
	
	@property
	def closed(self):
		if self._closed is not None:
			return(self._closed)
		else:
			self.forum.retrieve(t=self)
			return(self._closed)
	
	@property
	def deleted(self):
		if self._deleted is not None:
			return(self._deleted)
		else:
			self.forum.retrieve(t=self)
			return(self._deleted)
	
	@property
	def sticky(self):
		if self._sticky is not None:
			return(self._sticky)
		else:
			self.forum.retrieve(t=self)
			return(self._sticky)
	
	def mark_read(self):
		"""Marks the thread as read."""
		
		#ajax_data = {
		#	"securitytoken": self.session.get_security_token(),
		#	"do": "markread",
		#	"t": self.id,
		#}
		#
		#self.session.get_page("ajax.php?do=markread&t={0}".format(self.id), post_data=ajax_data)
		
		if self._latest_post and self._latest_post._id:
			thread_url = "showthread.php?p={0}".format(self.latest_post.id)
		else:
			thread_url = "showthread.php?goto=newpost&t={0}".format(self.id)
		self.session.get_page(thread_url)
		
		self._read = True
	
	def update_title(self, new_title):
		"""Changes the current title of the thread to a new title.
		
		Raises a `PermissionError` if the title could not be changed."""
		
		old_title = self.title
		
		ajax_data = {
			"securitytoken": self.session.get_security_token(),
			"do": "updatethreadtitle",
			"t": self.id,
			"title": new_title,
		}
		
		page = self.session.get_page("ajax.php?do=updatethreadtitle&t={0}".format(self.id), post_data=ajax_data)
		
		soup = self.session.soup(page, "xml")
		link = soup.find("linkhtml")
		if link.string:
			new_title = str(link.string)
		else:
			raise(forumapi.UnknownError("Could not change title."))
		
		if new_title == old_title:
			raise(forumapi.PermissionError("Could not change title."))
		
		self._title = new_title
	
	def retrieve(self, pages=None, start=1, p=None):
		"""Obtains the posts in the thread.
		
		By default only retrieves the first page of the thread; more pages can
		be retrieved using the `pages` parameter or a `p` parameter representing
		a Post that is to be found in the thread. See `Session.get_pages()`
		for more information.
		
		If this is given a `p`, its ID will be used in the URL so that the page
		that has that post will be retrieved, assuming that the post is in the
		thread."""
		
		page = "showthread.php"
		
		if p and p._id:
			query = {"p": p.id}
		else:
			query = {"t": self.id}
		
		if self._posts is None:
			# Send both a response and a page URL so that we don't end up 
			# keeping the `p` in the URL, for which `page` has no effect
			response = self.session.get_page(page, post_data=query, method="get")
			
			self._posts = self.session.get_pages(self.get_posts_list, page="{0}?t={1}".format(page, self.id), response=response, pages=pages, start=start, p=p)
		else:
			# Send `path` and `query` to temporarily override them
			self._posts.get_pages(path=page, query=query, pages=pages, start=start, p=p)
	
	def get_posts_list(self, result, data, page_soup, first_page=False, p=None, **kw):
		"""Given a thread page, retrieves a list of posts from the page.
		
		Returns a list of `Post` objects."""
		
		if first_page:
			self.session.retrieve_metadata(soup=page_soup, t=self, loc="thread")
			
			if p and p in result.items:
				return(False)
		
		posts_soup = page_soup.find(id="posts")
		
		if not posts_soup:
			return(False)
		
		continue_pages = True
		
		post_divs = posts_soup.findAll("div", recursive=False)
		
		last_count = 0
		
		for post_soup in post_divs:
			post_table = post_soup.find("table")
			
			ret = forumapi.hooks.run("vb_get_posts_list", **locals())
			if isinstance(ret, Post):
				post = ret
			elif ret == True:
				continue
			elif not post_table:
				# Deleted, invisible, whatever. Just ignore it for now.
				continue
			else:
				post_data = self.session.parse_postbit(post_table)
				
				if not post_data["count"]:
					post_data["count"] = last_count + 1
				
				last_count = post_data["count"]
				
				if post_data["edit"]:
					edits = [post_data["edit"]]
				else:
					edits = []
				
				del post_data["edit"]
				
				post = Post(self.session, thread=self, edits=edits, **post_data)
			
			if p == post:
				continue_pages = False
			
			data.append(post)
		
		return(continue_pages)
	
	def __eq__(self, other):
		if isinstance(other, Thread):
			if not(self._id and other._id):
				if self._poll and other._poll and self._poll._id and other._poll._id:
					return(self.poll == other.poll)
				elif self._forum and other._forum:
					# Not really exact if there are multiple threads with the
					# same name
					return(self.forum == other.forum and self.title == other.title and self.sticky == other.sticky)
				else:
					return(False)
			else:
				return(self.id == other.id)
		elif isinstance(other, int):
			return(self.id == other)
		elif isinstance(other, str):
			return(self.title == other)
		else:
			return(False)
	
	def __str__(self):
		return(self.title)
	
	def __int__(self):
		return(self.id)
	
	def __repr__(self):
		return("<vbulletin.Thread {self.id} \"{self.title}\" in f{self.forum.id}>".format(self=self))

class Forum(forumapi.Forum):
	"""A forum. It knows its ID, and might know its name and threads."""
	
	@classmethod
	def obj(cls, session, *a, **kw):
		if isinstance(a[0], cls):
			return(a[0])
		elif isinstance(a[0], int):
			return(cls(session, *a[1:], id=a[0], **kw))
		elif isinstance(a[0], str):
			return(cls(session, *a[1:], name=a[0], **kw))
		elif a[0] == False:
			return(False)
		else:
			return(None)
	
	def __init__(self, session, id=None, name=None, parent=None, subforums=None, threads=None, sticky_threads=None):
		if not (id or name):
			raise(ValueError("ID or name required."))
		
		self.session = session
		
		self._id = id
		self._name = name
		
		self._parent = Forum.obj(session, parent)
		
		if isinstance(subforums, list):
			self._subforums = [Forum.obj(session, subforum, parent=self) for subforum in subforums]
		elif subforums is not None:
			self._subforums = [Forum.obj(session, subforums, parent=self)]
		else:
			self._subforums = None
		
		if isinstance(threads, list):
			self._threads = [Thread.obj(session, thread, forum=self) for thread in threads]
		else:
			self._threads = threads
		
		if isinstance(sticky_threads, list):
			self._sticky_threads = [Thread.obj(session, thread, forum=self, sticky=1) for thread in sticky_threads]
		else:
			self._sticky_threads = None
		
		self._find_instances()
	
	def _merge(self, other):
		if self._id:
			other._id = self._id
		if self._name:
			other._name = self._name
		
		if self._parent is not None:
			other._parent = self._parent
		
		self._merge_list(other, "_subforums")
		
		self._merge_obj(other, "_threads")
		self._merge_list(other, "_sticky_threads")
	
	@property
	def data(self):
		return({
			"id": self._id,
			"name": self._name,
			"parent": False if self._parent == False else (int(self.parent) if self._parent is not None else None),
			"subforums": [int(subforum) for subforum in self.subforums] if self._subforums is not None else None,
			"threads": forumapi._flatten_data(self._threads),
			"sticky_threads": [int(thread) for thread in self.sticky_threads] if self._sticky_threads is not None else None,
		})
	
	@property
	def id(self):
		if self._id:
			return(self._id)
		else:
			self.session.get_forums_list(loc="index")
			if self._id:
				return(self._id)
			else:
				raise(forumapi.ForumAPIException("Unable to retrieve forum ID."))
	
	@property
	def name(self):
		if self._name:
			return(self._name)
		else:
			self.retrieve()
			return(self._name)
	
	@property
	def parent(self):
		if self._parent:
			return(self._parent)
		else:
			self.retrieve()
			return(self._parent)
	
	@property
	def subforums(self):
		if self._subforums is not None:
			return(self._subforums)
		else:
			self.retrieve()
			return(self._subforums)
	
	@property
	def threads(self):
		if self._threads is not None:
			return(self._threads)
		else:
			self.retrieve()
			return(self._threads)
	
	@property
	def sticky_threads(self):
		if self._sticky_threads is not None:
			return(self._sticky_threads)
		else:
			self.retrieve()
			return(self._sticky_threads)
	
	def retrieve(self, pages=None, start=1, t=None):
		"""Obtains the threads and subforums in the forum.
		
		By default only retrieves the first page of the forum; more pages can
		be retrieved using the `pages` parameter or a `t` parameter representing
		a Thread that is to be found in the forum. See `Session.get_threads()`
		for more information."""
		
		if self._threads is None:
			self._threads = self.session.get_threads(page="forumdisplay.php?f={0}".format(self.id), pages=pages, start=start, f=self, t=t)
		else:
			self._threads.get_pages(pages=pages, start=start, f=self, t=t)
	
	def mark_read(self):
		"""Marks the forum as read."""
		
		ajax_data = {
			"securitytoken": self.session.get_security_token(),
			"do": "markread",
			"forumid": self.id,
		}
		
		self.session.get_page("ajax.php?do=markread&f={0}".format(self.id), post_data=ajax_data)
	
	def __eq__(self, other):
		if isinstance(other, Forum):
			if self._id and other._id:
				return(self.id == other.id)
			else:
				# If possible, compare parents
				return(self.name == other.name and (not ((self._parent is not None or self._id) and (other._parent is not None or other._id)) or self.parent == other.parent))
		elif isinstance(other, int):
			return(self.id == other)
		elif isinstance(other, str):
			return(self.name == other)
		else:
			return(False)
	
	def __str__(self):
		return(self.name)
	
	def __int__(self):
		return(self.id)
	
	def __repr__(self):
		return("<vbulletin.Forum {self.id} \"{self.name}\">".format(self=self))

class PreparedPost(forumapi.PreparedPost):
	"""A form that's ready to be made in a thread. It has `.message` and
	`.title` properties."""
	
	@property
	def message(self):
		return(self.post_data["message"])
	
	@message.setter
	def message(self, value):
		self.post_data["message"] = value
	
	@property
	def title(self):
		if self.type == "createthread":
			return(self.post_data["subject"])
		else:
			return(self.post_data["title"])
	
	@title.setter
	def title(self, value):
		if self.type == "createthread":
			self.post_data["subject"] = value
		else:
			self.post_data["title"] = value
	
	def submit(self):
		"""Submits the form with the associated data. Returns an object
		representing whatever that was just posted."""
		
		if self.posted:
			raise(forumapi.DuplicateError("Post already submitted."))
		
		result = self.post_data.submit()
		
		response_qs = forumapi.qs_dict_from_url(result.response.geturl())
		
		self.posted = True
		
		try:
			if self.type == "postreply":
				id = int(response_qs["p"][0])
				
				post = Post(self.session, id=id, thread=self.thread, content=self.post_data["message"], title=self.post_data["title"], user=self.session.user)
				
				# TODO: Ideally we should also append the post to Thread.posts,
				# but PagedResult might not like that
				if isinstance(self.thread, Thread):
					if self.thread._latest_post and self.thread.latest_post._count:
						post._count = self.thread.latest_post._count + 1
					
					self.thread._latest_post = post
				
				return(post)
			elif self.type == "editpost":
				if isinstance(self.post, Post):
					self.post._content = self.post_data["message"]
					return(self.post)
				else:
					return(Post(self.session, id=int(response_qs["p"][0]), content=self.post_data["message"]))
			elif self.type == "createthread":
				id = int(response_qs["p"][0])
				
				post = Post(self.session, id=id, content=self.post_data["message"])
				
				sticky = ("stickunstick" in post_data and post_data["stickunstick"])
				thread = Thread(self.session, title=self.post_data["subject"], forum=self.forum, posts=[post], post_count=1, own_posts=1, first_post=post, sticky=sticky)
				
				forumapi.hooks.run("vb_prepared_post_submit_thread", **locals())
				
				return(thread)
			else:
				return(forumapi.hooks.run("vb_prepared_post_submit", **locals()))
		except Exception:
			error = self.session.get_error(response=result)
			if error:
				exc = forumapi.PermissionError
			else:
				exc = forumapi.UnknownError
				error = "I have no idea." + "\n\nResponse: " + repr(response_qs)
			
			raise(exc(error))
	
	def __repr__(self):
		return("<vbulletin.PreparedPost \"{self.type}\"{0}{1}>".format(" posted" if self.posted else "", " for {0}{1}".format(*self.identifier) if self.identifier else "", self=self))
